異步和非同步的叫法上,我個人比較喜歡異步,所以用詞上會偏向這個叫法,但指涉的對象是同一個。
在討論完事件循環後,我們知道了 JS 中安排異步事件的機制,參與其中的 Call Stack 和 Task Queue,甚至是 Microtask Queue。
這篇我們會來探討實際 JS 過往相關的 callback function,過去的問題,現在的實作。
每個異步函式都會有對應的觸發條件,有時候我們必須要掌控事件發生順序,達到條件 a 後執行內容 A,且設置下一個異步函式,達到條件 b 後執行內容 B,而 b 必須在 a 達到後且執行完 A 才能執行。
用料理來舉個例子好了,恩,我們來煮個水煮蛋吧!
console.log("開始煮水");
setTimeout(() => {
    console.log("水煮開了,把蛋放進水裡");
    setTimeout(() => {
        console.log("等待蛋煮熟");
        setTimeout(() => {
            console.log("蛋煮好了,把蛋撈起來");
            setTimeout(() => {
                console.log("蛋放涼了,可以吃了");
            }, 1000);
        }, 1000);
    }, 1000);
}, 1000);
//Print as
/*
"開始煮水"
"水煮開了,把蛋放進水裡"
"等待蛋煮熟"
"蛋煮好了,把蛋撈起來"
"蛋放涼了,可以吃了"
*/
煮水煮蛋也是有很多步驟,就像上面的函式連鎖,步驟間有先後順序,沒有先煮水,就沒辦法煮蛋。
為了先後順序的關係安排,導致了多層函示的嵌套。
這個是一個有壞味道(bad smell)的寫法,一如使用條件控制時寫出多個嵌套的情況。
多個嵌套的缺點是造成程式難以維護。
假設今天不是一個這麼簡單的例子,想像一下每個 setTimeout 裡面都有個 20 行以上,要確認自己到底在哪個 {} 中,就不是件容易的事。即使抽成 function,在 IDE 裡多層嵌套也不易於閱讀。
在 ES 6 以前,儘管回呼函式(callback function)就已存在,但多層回呼就會有這樣的缺點。
到了 ES 6,為了解決這個問題,推出了新的語法來改善這種狀況:Promise。
Promise 指的是一個表示異步結果的物件。
一樣,我們看看水煮蛋如果我們改以 Promise 寫,可以寫成怎麼樣子。
console.log("開始煮水");
function boilWater() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log("水煮開了,把蛋放進水裡");
            resolve();
        }, 1000);
    });
}
function cookEgg() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log("等待蛋煮熟");
            resolve();
        }, 1000);
    });
}
function finishBoiling() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log("蛋煮好了,把蛋撈起來");
            resolve();
        }, 1000);
    });
}
function coolEgg() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log("蛋放涼了,可以吃了");
        }, 1000);
    });
}
boilWater()
    .then(cookEgg)
    .then(finishBoiling)
    .then(coolEgg);
是不是可以更直觀的看到整個流程,且清晰的知道個流程中發生的事情?
這個寫法解決了 {} 的多層嵌套,而是改為使用 . 來鏈接,看上去更為直觀。
我們來看看 Promise 用到的語法。
剛剛說的,Promise 是個承載異步執行結果的物件,所以可以看到 boilWater 裡我們這樣寫:new Promise(...)。
容我再從 MDN 借圖:

當我們有了一個承諾(Promise),一開始會先是等待的狀態(pending,初始狀態),直到得到結果。
結果只有兩種可能性:實現(fulfill)或拒絕(reject),根據結果可以繼續做後續處理(then),回傳對應的內容(return)。
相關狀態詞也可能使用 settle 來表示一個被解決的承諾,同樣中文翻成「解決」的另一個字 resolve 則表示承諾被實現了(fulfill)。雖然中文一樣,但兩者的意思是不同的,使用或討論上要注意(寫英文就沒問題啦)。
下面這段也是從 MDN 拿來的範例:
const promiseObj = new Promise((resolve, reject) => {
  // 執行一些異步作業,最終在異步作業裡呼叫:
  //   resolve(someValue); // 實現
  // 或
  //   reject("failure reason"); // 拒絕
});
初始化 Promise 物件時,需要傳入的參數是兩個函式,一個於承諾實現時執行,一個於承諾失敗時執行。
function boilWater() {
    return new Promise((resolve) => {
        setTimeout(() => {
            console.log("水煮開了,把蛋放進水裡");
            resolve();
        }, 1000);
    });
}
這段沒放 reject 是因為有些函式幾乎不可能失敗,像計時這種函式。但如果今天是一個 HTTP 呼叫遠端 API的情景,那你永遠無法確定對方到底會丟什麼回來給你,或甚至會不會回應你,那就需要定義失敗時各種對應場景的處理。
在 Promise 中,我們通過調用 resolve() 或 reject() 來表達實現或拒絕。此外,Promise 物件底下有三個實體方法:then, finally, catch。
then
用於繫結 Promise 解決後對應狀態執行函式,傳入一個是實現,和一個是拒絕。
p.then(onFulfilled[, onRejected]);
p.then(function(value) {
// fulfillment
}, function(reason) {
// rejection
});
進入實現函式的狀況:
進入拒絕函的狀況:
then 最後回傳一個 Promise物件,若進入實現函式則該物件狀態為實現,進入拒絕函式則為拒絕。一經設定 Promise 狀態,便無法再度更改。
也就是當 Promise 任一層失敗後,往外會告訴你在上一步失敗了,會繼續以失敗狀態外傳出去。
function boilWater(waterAmount) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            if (waterAmount < 1) {
                reject("水量太少");
            } else {
                console.log("水煮開了,把蛋放進水裡");
                resolve();
            }
        }, 1000);
    });
}
boilWater(0)
    .then(
        () => {
            console.log("水煮開了,繼續煮蛋");
        },
        (error) => {
            console.log(error);
            console.log("加了足夠的水");
            return boilWater(2); // 補救後重新執行 boilWater
        }
    ).then(
        ()=>{
        console.log("等待蛋煮熟中");
    },
    (error)=>{
        console.log("好像在某步開始失敗了");
    }
    );
比如這個例子,水煮開的時候如果發現水煮太少了,我們就補立刻補水,加完水再調用 boilWater() 來重新加熱。
如果我們在失敗狀況下不再次執行 boilWater,則最後會回傳 好像在某步開始失敗了,因為最後往外傳的是一個拒絕狀態的 Promise 物件。
catch
上面的例子在 then 裡面寫了兩個匿名函示,讀起來是不是有些彆扭?
可能也會有搞不清哪個函式是對應哪個狀況的時候?
catch 就這樣被介紹了,他是專門用來接 Promise 失敗的關鍵字。有種實踐是,用 then 來接實現,用 catch 來接拒絕。
使用 catch 來改寫上面的例子的一部分:
...
boilWater(0)
.then(() => {
    console.log("水煮開了,繼續煮蛋");
})
.catch((error) => {
    console.log(error);
    console.log("加了足夠的水");
    return boilWater(2); // 補救後重新執行 boilWater
})
.then(() => {
    console.log("等待蛋煮熟中");
})
.catch(() => {
    console.log("好像在某步開始失敗了");
});
每個 catch 僅指上一個的 then 的範圍,這樣寫是不是更能區分各個狀態的對應處理?
catch 最後會回傳一個狀態為拒絕的 Promise 物件。
finallyfinally 提供一種當你想要無論承諾物件本身狀態為拒絕或實現都會觸及的塊域,且不關心承諾狀態時使用。
比如你想做一些清理或紀錄日誌之類的行為,或避免在 then 和 catch 中處理重複的程式碼。
上面特別提到不關心承諾狀態,是因為 finally 並不帶任何參數傳入,它不能確定承諾當下是成功或失敗的。
finally 也會繼續回傳一個 Promise 物件,該物件狀態會和 then 中的規則一樣,根據你的傳入 Promise 狀態繼續往下傳遞。
boilWater(0)
.then(() => {
    console.log("水煮開了,繼續煮蛋");
})
.catch((error) => {
    console.log(error);
    console.log("加了足夠的水");
    return boilWater(2);
})
.finally(()=>{console.log("整理一下檯面"); });//不管如何,都要好好收拾
在 Promise 推出後,解決了回呼地獄的狀況,同時,後續也有許多語法是基於 Promise 做改寫或延伸。
比如過往再處理呼叫遠端的 API 的時候,會使用的語法是 XMLHttpRequest,在 ES 6 後,隨著 Promise 的推出,在處理異步的 API 呼叫時,有了新的 fetch 函式可以使用。fetch 函示會長得像這樣:
fetch('https://....remote api endpoint')
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  })
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error('There was a problem with the fetch operation:', error);
  });
看上去是不是很像 Promise 的語法?因為 fetch 就是基於 Promise 來實作的,fetch 函式會回傳一個 Promise 物件,後面的處理就回到了上面討論 Promise 的相關方法中了。
比如今天第一個 API 呼叫要用 Get 拿到存取 Token,拿到後才進行後面第二次呼叫,否則進行錯誤處理,用 fetch 就能寫成漂亮的鏈式表示,增加整段程式碼的可讀性。
關於同步異步,我們還有關鍵字沒有講到 Generator 和 async await。
下篇我們先小小聊一下關於迭代器(Iterator)後,我們再回來繼續。